10.04.2025 Containerisierung mit Docker ====================================================== .. _einleitung: Einleitung ------------ In modernen IT-Umgebungen steigt der Bedarf an modularen, skalierbaren und leicht wartbaren Systemen. Klassische monolithische Anwendungen sind schwergewichtig und schwer zu skalieren. Container-Technologien wie Docker ermöglichen es, Anwendungen in einzelne, voneinander entkoppelte Dienste (Microservices) aufzuteilen, die unabhängig voneinander entwickelt, betrieben und skaliert werden können. Docker bietet dabei eine standardisierte Plattform zur Ausführung von Anwendungen in isolierten Umgebungen – sogenannten **Containern**. Jeder Container enthält alles, was die Anwendung zum Laufen braucht: Code, Laufzeit, Systemtools, Bibliotheken und Konfiguration. .. _grundlagen: Grundlagen ----------- Vorteile von Docker ``````````````````````` - **Portabilität:** Container laufen identisch auf jeder Plattform, unabhängig vom Hostsystem. - **Schneller Aufbau:** Neue Instanzen können in Sekunden bereitgestellt werden. - **Isolierung:** Jeder Container läuft unabhängig – Fehler in einem Dienst beeinflussen andere nicht. - **Effizienz:** Container teilen sich den Kernel des Hostsystems – geringerer Ressourcenbedarf als virtuelle Maschinen. - **Skalierbarkeit:** Container können leicht horizontal skaliert und verteilt werden (z. B. über Load Balancer). Unser Versuchsaufbau: Eine containerisierte TODO-Webanwendung ............................................................... Zur Demonstration des containerbasierten Aufbaus von Microservices wurde eine einfache webbasierte TODO-Anwendung entwickelt. Diese besteht aus fünf logisch getrennten Komponenten, die jeweils in einem eigenen Container ausgeführt werden: .. list-table:: :widths: 20 80 :header-rows: 1 * - **Komponente** - **Beschreibung** * - Load Balancer - Verteilt HTTP-Anfragen im Least-Connections-Verfahren auf zwei identische Frontend-Instanzen (NGINX). * - Frontend 1 & 2 - Zwei identische Benutzeroberflächen, umgesetzt mit Streamlit, erreichbar über Port 80. * - Backend - Eine REST-API, implementiert mit Flask, die Benutzerdaten und Einträge verarbeitet und verwaltet. * - Redis (DB) - Key-Value-Store für die Speicherung der Benutzer- und Eintragsdaten inklusive Persistenz per Volume. Ziel des Projekts war es, nicht nur eine funktionale Webanwendung zu erstellen, sondern vor allem das **Zusammenspiel containerisierter Komponenten** zu demonstrieren. Benutzerinteraktionen durchlaufen dabei mehrere Container-Schichten: Vom UI über den Load Balancer bis hin zur persistenten Datenspeicherung in Redis. Service-Orchestrierung ```````````````````````````` Grundlagen der Service-Orchestrierung .......................................... Service-Orchestrierung bietet einen zentralen Mechanismus zur Verwaltung der Serviceinteraktionen und Workflow-Prozesse. Im Gegensatz zur Choreographie, bei der Services unabhängig kommunizieren, gibt es bei der Orchestrierung einen zentralen Controller. Vorteile: - Zentrale Kontrolle über den Workflow - Vereinfachte Fehlerbehandlung - Bessere Sichtbarkeit des Gesamtprozesses Werkzeuge zur Service-Orchestrierung ................................... Es gibt verschiedene Tools und Frameworks, die bei der Orchestrierung von Microservices helfen: 1. **Kubernetes**: Ein Container-Orchestrierungssystem zur Automatisierung der Bereitstellung und Skalierung von Anwendungen. 2. **Apache Camel**: Ein Framework zur Implementierung von Enterprise Integration Patterns. 3. **Netflix Conductor**: Ein Framework zur Orchestrierung von Microservices-basierten Prozessen. 4. **Docker Compose**: Ein Tool zur Definition und Ausführung von Multi-Container-Docker-Anwendungen. Service-Orchestrierung mit Docker Compose .......................................... Docker Compose ist ein leistungsstarkes Werkzeug, das die Orchestrierung mehrerer Docker-Container erleichtert. Es ermöglicht die Definition und Verwaltung mehrerer Dienste in einer einzigen YAML-Datei, was die Komplexität der Microservice-Architektur erheblich reduziert. Grundkonzepte von Docker Compose ................................... Docker Compose basiert auf folgenden Kernkomponenten: - **Services**: Die Container-Anwendungen, die Teil der Gesamtanwendung sind - **Networks**: Kommunikationskanäle zwischen den Containern - **Volumes**: Persistente Daten, die auch nach dem Neustart der Container erhalten bleiben Struktur der docker-compose.yml ................................... Die zentrale Konfigurationsdatei für Docker Compose ist die ``docker-compose.yml``. In unserem Projekt ist sie wie folgt strukturiert: .. code-block:: yaml version: '3.9' # Docker Compose Version services: redis: # Datenbankservice image: redis:alpine container_name: redis ports: - "6379:6379" volumes: - redis_data:/data backend: # Backend-Service build: ./backend container_name: backend ports: - "5000:5000" depends_on: - redis frontend1: # Erste Frontend-Instanz build: ./frontend1 container_name: frontend1 depends_on: - backend frontend2: # Zweite Frontend-Instanz build: ./frontend2 container_name: frontend2 depends_on: - backend loadbalancer: # Load Balancer für die Frontends image: nginx:alpine container_name: loadbalancer ports: - "80:80" volumes: - ./loadbalancer/default.conf:/etc/nginx/conf.d/default.conf depends_on: - frontend1 - frontend2 volumes: redis_data: # Persistenter Speicher für Redis-Daten Schlüsselelemente der Service-Orchestrierung in unserem Projekt ............................................................... 1. **Container-Abhängigkeiten** Docker Compose ermöglicht die Definition von Abhängigkeiten zwischen Services mit der ``depends_on`` Anweisung. In unserem Projekt starten die Frontend-Services erst, nachdem der Backend-Service gestartet ist, und der Backend-Service wartet auf Redis: .. code-block:: yaml backend: depends_on: - redis frontend1: depends_on: - backend Dies stellt sicher, dass Services in der richtigen Reihenfolge gestartet werden. 2. **Load Balancing** Unser Projekt implementiert ein klassisches Load-Balancing-Muster mit zwei Frontend-Instanzen hinter einem Nginx-Load-Balancer: - Der Nginx-Service leitet den Datenverkehr an die Frontend-Services weiter - Die Konfiguration wird über ein gemapptes Volume bereitgestellt - Dies ermöglicht horizontale Skalierung der Frontend-Anwendung 3. **Datenpersistenz** Für die Datenpersistenz nutzen wir Docker Volumes: .. code-block:: yaml volumes: redis_data: Der Redis-Service speichert seine Daten in diesem Volume, wodurch die Daten auch bei Neustarts oder Updates der Container erhalten bleiben. 4. **Netzwerkkommunikation** Docker Compose erstellt automatisch ein gemeinsames Netzwerk für alle definierten Services. Die Services können sich gegenseitig über ihre Service-Namen ansprechen, was die interne Kommunikation vereinfacht: - Der Backend-Service kann Redis über den Hostnamen "redis" erreichen - Die Frontend-Services können den Backend-Service über den Hostnamen "backend" erreichen - Der Load Balancer kann die Frontend-Services über ihre Namen "frontend1" und "frontend2" erreichen Vorteile der Orchestrierung mit Docker Compose ................................................. 1. **Einfachheit**: Im Vergleich zu komplexeren Orchestrierungslösungen wie Kubernetes ist Docker Compose einfacher zu erlernen und einzusetzen. 2. **Deklarative Konfiguration**: Die gesamte Anwendungsinfrastruktur wird in einer einzigen YAML-Datei definiert, was die Versionierung und das Deployment vereinfacht. 3. **Entwicklungsfreundlichkeit**: Docker Compose eignet sich hervorragend für Entwicklungs- und Testumgebungen, da es schnell auf- und abgebaut werden kann. 4. **Portabilität**: Die Anwendung kann auf jedem System ausgeführt werden, auf dem Docker und Docker Compose installiert sind. Grenzen von Docker compose ............................ Für komplexere Produktionsumgebungen mit höheren Anforderungen an Skalierbarkeit, Ausfallsicherheit und Lastverteilung stößt Docker Compose an seine Grenzen. In solchen Fällen sind fortgeschrittenere Orchestrierungslösungen wie Kubernetes oder Docker Swarm vorzuziehen. Kommunikation zwischen Docker-Containern ```````````````````````````` Eine der zentralen Herausforderungen in einer containerisierten Microservice-Architektur ist die zuverlässige, effiziente und sichere Kommunikation zwischen den einzelnen Containern. Da Container in isolierten Umgebungen laufen, müssen sie über definierte Schnittstellen und Netzwerkverbindungen miteinander kommunizieren können. In unserem Projekt – bestehend aus Load Balancer, Frontends, Backend und Redis – spielt die Kommunikation eine zentrale Rolle für den Datenaustausch und die Funktionsfähigkeit des Gesamtsystems. Docker nutzt standardmäßig ein sogenanntes **Bridge-Netzwerk**, wenn mehrere Container über ``docker-compose`` gestartet werden. Innerhalb dieses virtuellen Netzwerks können sich Container gegenseitig über ihre **Service-Namen** ansprechen – ähnlich wie Hostnamen in einem DNS-System. Dies ermöglicht eine einfache und dynamische Adressierung der Dienste, ohne IP-Adressen manuell konfigurieren zu müssen. Beispielhafte Kommunikation in unserem Projekt: - Die Frontend-Container (Streamlit) senden HTTP-Anfragen an die API unter ``http://backend:5000`` - Das Backend (Flask) kommuniziert mit Redis über ``redis:6379`` mittels TCP-Protokoll - Der Load Balancer (nginx) verteilt HTTP-Anfragen auf die beiden Frontends ``frontend1`` und ``frontend2`` Docker Compose sorgt dabei automatisch für die Einrichtung eines gemeinsam genutzten Netzwerks, sofern in der ``docker-compose. yml`` nichts anderes konfiguriert wird. Die Konnektivität ist direkt nach dem Start gegeben – es ist keine manuelle Netzwerkdefinition notwendig. Im YAML-File sieht das folgendermaßen aus: .. code-block:: yaml services: backend: build: ./backend depends_on: - redis redis: image: redis:alpine networks: default: Durch das gemeinsam genutzte **default-Netzwerk** können sich alle Services gegenseitig erreichen, ohne zusätzliche Ports freigeben zu müssen (außer bei externem Zugriff). Ein weiterer Vorteil: **Service-Isolation nach außen. ** Nur Dienste, die explizit mit ``ports:`` veröffentlicht werden, sind von außerhalb des Docker-Hosts erreichbar. In unserem Setup trifft das beispielsweise auf ``loadbalancer:80`` oder ``backend:5000`` zu. Redis hingegen ist rein intern verfügbar und damit vor Zugriffen von außen geschützt. Zusätzlich zur Namensauflösung und Isolation bietet Docker weitere Möglichkeiten zur **Feinsteuerung der Kommunikation**, z.B.: - **Benutzerdefinierte Netzwerke:** Ermöglichen granulare Kontrolle über Sichtbarkeit und Erreichbarkeit von Containern - **Multiple Netzwerke pro Container:** Ein Container kann in mehreren Netzwerken aktiv sein – z. B. zur Trennung von Frontend- und Backend-Traffic - **Healthchecks und Restart-Strategien:** Sichern die dauerhafte Verfügbarkeit einzelner Services und verhindern Kommunikation mit fehlerhaften Instanzen Im praktischen Projektverlauf zeigte sich, dass die Kommunikation zwischen den Containern äußerst zuverlässig verlief – vorausgesetzt, die ``depends_on``-Reihenfolge war korrekt definiert und alle Services waren bereit. Für besonders kritische Anwendungsfälle kann zusätzlich ein **Service-Wait-Skript** eingesetzt werden, das auf den erfolgreichen Start abhängiger Dienste wartet (z. B. Redis-Response), bevor die Anwendung fortfährt. Insgesamt stellt die interne Container-Kommunikation einen der größten Vorteile von Docker Compose dar: Dienste lassen sich modular entwickeln, testen und deployen – und kommunizieren dennoch nahtlos über ein gemeinsames virtuelles Netzwerk. .. image:: img/container_kommunikation.png :alt: Kommunikation zwischen Containern :width: 600px :align: center Best Practices: Netzwerke statt globaler Portfreigaben ......................................................... Bei der Konzeption containerbasierter Systeme sollte die Kommunikation zwischen Containern ausschließlich über Docker-interne Netzwerke erfolgen – und nicht über global freigegebene Ports. Docker stellt hierfür automatische Netzwerkfunktionen bereit, die es ermöglichen, dass Container untereinander über ihre Servicenamen kommunizieren können, ohne dass Ports explizit an den Host gebunden werden müssen. Globale Portfreigaben über ports: in der docker-compose. yml – z. B. 5000:5000 – machen einen Container-Dienst von außen über den Host erreichbar. Dies ist zwar für die Entwicklung oder für externe Schnittstellen notwendig (etwa um die Webanwendung im Browser aufzurufen), sollte aber nicht für interne Container-Kommunikation verwendet werden. Die Gründe: 1. Sicherheitsrisiko: Jeder geöffnete Port stellt eine potenzielle Angriffsfläche dar. 2. Firewall-Bypass: Docker konfiguriert eigene iptables-Regeln, die in vielen Fällen vorhandene Firewallregeln umgehen oder aushebeln – ein bewusst geöffneter Port ist also direkt zugänglich, auch wenn die Systemfirewall dies eigentlich verbieten würde. 3. Netzwerkisolierung wird unterlaufen: Der Sinn einer serviceorientierten Microservice-Architektur mit isolierten Containern wird untergraben, wenn Dienste öffentlich zugänglich sind, obwohl sie ausschließlich intern benötigt werden. Best Practice ist daher: Nur externe Schnittstellen (z. B. Frontend oder API-Gateway) über Ports exposen, alle anderen Dienste (z. B. Redis, interne APIs) hingegen ausschließlich im internen Docker-Netzwerk kommunizieren lassen. Dies erhöht nicht nur die Sicherheit, sondern reduziert auch den Konfigurationsaufwand und minimiert unbeabsichtigte Seiteneffekte beim Routing und Load Balancing. Web-Stack in Containern `````````````````` Was ist Streamlit? .................... **Streamlit** ist ein modernes Open-Source-Framework, mit dem sich interaktive Webanwendungen direkt in Python erstellen lassen – ohne zusätzliches Wissen in HTML, CSS oder JavaScript. Es wurde speziell für Datenwissenschaftler:innen und Entwickler:innen entwickelt, die schnell visuelle Oberflächen oder Dashboards benötigen, ohne sich mit klassischen Webtechnologien auseinandersetzen zu müssen. Im Gegensatz zu traditionellen Web-Frameworks wie Flask oder Django arbeitet Streamlit deklarativ. Das bedeutet: Der Code beschreibt den Zustand der Benutzeroberfläche und wird bei jeder Benutzerinteraktion vollständig neu ausgewertet. So bleibt die Logik sehr einfach und vorhersehbar. Warum Streamlit in unserem Projekt? ................................... In unserem Docker-Projekt nutzen wir **Streamlit als Frontend**, also als Benutzeroberfläche. Die Gründe dafür sind: - **Schnelle Entwicklung**: Eine funktionale UI ist in wenigen Minuten umsetzbar. - **Volle Python-Integration**: Alle Datenverarbeitungsschritte, API-Requests oder Visualisierungen passieren in Python. - **Keine Webdesign-Kenntnisse notwendig**: Die Benutzeroberfläche sieht ansprechend aus – ganz ohne HTML oder CSS. - **Einfach zu bedienen**: Ideal für Projekte mit Fokus auf Funktion statt Design. - **Gut integrierbar in Containerstrukturen**: Streamlit lässt sich einfach per ``Dockerfile`` in ein Service-Setup einbinden. So funktioniert Streamlit .......................... Ein typisches Streamlit-Skript besteht aus einem linearen Python-Code, der beim Aufruf der Web-App von oben nach unten ausgeführt wird. Es verwendet sogenannte **Widgets**, also Eingabeelemente (Textfelder, Buttons, Slider etc.), die den Zustand der App beeinflussen. Alle Änderungen an diesen Eingabeelementen führen dazu, dass der gesamte Code erneut ausgeführt wird – ähnlich wie in einem Jupyter Notebook mit interaktiven Zellen. Beispiel 1: Benutzerverwaltung ............................... Das folgende Beispiel zeigt, wie man in Streamlit eine einfache Benutzerverwaltung mit Auswahl, Bearbeitung und Löschung umsetzt. .. image:: img/streamlit_benutzer.png :alt: Benutzerverwaltung in Streamlit :width: 600px :align: center **Erklärung der Elemente:** - ``st.selectbox(...)`` – Dropdown zur Auswahl eines vorhandenen Benutzers - ``st.columns(...)`` – Spaltenlayout für Buttons - ``st.text_input(...)`` – Eingabefeld zum Ändern des Benutzernamens - ``st.button(...)`` – Auslöser für Löschen und Aktualisieren **Beispielcode:** .. code-block:: python user_names = {u['name']: u['id'] for u in users} selected_user_name = st.selectbox("Benutzer auswählen", ["-- Benutzer wählen --"] + list(user_names.keys())) if selected_user_name != "-- Benutzer wählen --": selected_user_id = user_names[selected_user_name] col1, col2 = st.columns([1, 1]) with col1: if st.button("Benutzer löschen"): requests.delete(f"{API_URL}/users/{selected_user_id}") st.rerun() with col2: new_name = st.text_input("Benutzernamen ändern", value=selected_user_name) if st.button("Aktualisieren") and new_name.strip(): requests.put(f"{API_URL}/users/{selected_user_id}", json={"name": new_name.strip()}) st.rerun() Was ist Flask? ............... **Flask** ist ein leichtgewichtiges, modular aufgebautes Web-Framework für Python, das es ermöglicht, schnell und einfach HTTP-basierte Schnittstellen (APIs) zu entwickeln. Es basiert auf dem WSGI-Standard und eignet sich hervorragend für Microservice-Architekturen, da es bewusst minimalistisch gehalten ist – du verwendest nur die Komponenten, die du wirklich brauchst. Im Vergleich zu größeren Frameworks wie Django verzichtet Flask auf ein festes Projektgerüst und lässt dir mehr Freiheit bei der Strukturierung deines Codes. Dies macht es besonders geeignet für schlanke Backend-Dienste in containerisierten Anwendungen. Warum Flask in unserem Projekt? ................................. In unserer Container-Architektur übernimmt Flask die Rolle des **Backends**. Es nimmt HTTP-Anfragen entgegen (z. B. von einem Frontend wie Streamlit), verarbeitet Daten, kommuniziert mit Redis oder anderen Services und sendet eine Antwort zurück. Vorteile im Projektkontext: - **Einfaches Routing**: HTTP-Endpunkte sind schnell definiert. - **Klein und performant**: Keine unnötige Overhead. - **Gute Erweiterbarkeit**: Z. B. für Validierung, Logging oder Authentifizierung. - **Perfekt für REST-APIs** geeignet. - **Leicht dockerisierbar**. Beispiel: Eine einfache Flask-API .................................. Nachfolgend ein Beispiel für eine einfache Flask-Anwendung, die Benutzer sowie zugehörige Einträge verwaltet. In einer späteren Version werden die Daten in Redis gespeichert. .. code-block:: python from flask import Flask, request, jsonify import uuid app = Flask(__name__) # In-Memory-Speicher (wird später durch Redis ersetzt) users = {} entries = {} @app.route('/users', methods=['POST']) def create_user(): data = request.json user_id = str(uuid.uuid4()) data['id'] = user_id users[user_id] = data entries[user_id] = [] return jsonify(data), 201 @app.route('/users', methods=['GET']) def get_users(): return jsonify(list(users.values())) @app.route('/users//entries', methods=['POST']) def add_entry(user_id): data = request.json data['id'] = str(uuid.uuid4()) entries[user_id].append(data) return jsonify(data), 201 @app.route('/users//entries', methods=['GET']) def get_entries(user_id): return jsonify(entries.get(user_id, [])) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000) Erklärung wichtiger Zeilen ........................... .. list-table:: :widths: 40 60 :header-rows: 1 * - **Zeile** - **Funktion** * - ``users = {}`` / ``entries = {}`` - Temporärer Speicher für Benutzer und Einträge * - ``uuid.uuid4()`` - Erzeugt eindeutige IDs für User und Einträge * - ``@app.route('/users', methods=['POST'])`` - Fügt einen neuen Benutzer hinzu * - ``@app.route('/users', methods=['GET'])`` - Gibt alle Benutzer zurück * - ``@app.route('/users//entries')`` - Fügt Einträge hinzu bzw. listet sie für einen Benutzer API-Design im Projekt ........................ Die API bildet die **Schnittstelle zwischen dem Frontend (Streamlit)** und der **Datenhaltung (später Redis)**. Die aktuelle Logik: - ``POST /users`` legt einen neuen Benutzer mit eindeutiger ID an - ``GET /users`` gibt alle angelegten Benutzer zurück - ``POST /users//entries`` fügt einen Eintrag (Thema, Datum, Zeit) zum Benutzer hinzu - ``GET /users//entries`` gibt alle Einträge eines Benutzers zurück Damit ist die API **benutzerorientiert aufgebaut** und entspricht den Prinzipien einer **klar strukturierten REST-Schnittstelle**, wie sie in modularen Systemen üblich ist. Ausblick: Anbindung an Redis ............................. Der nächste Schritt ist die Integration eines **Key-Value-Stores (Redis)**. Dabei werden: - Benutzerbezogene Einträge unter Schlüsseln wie ``user_entries:`` abgelegt Datenhaltung mit Redis (Key-Value-Store) ``````````````````````````````````````` Was ist Redis? .............. **Redis** (REmote DIctionary Server) ist ein **In-Memory Key-Value-Store**, der sich hervorragend für performante Datenhaltung eignet. Redis speichert Daten standardmäßig im Arbeitsspeicher, wodurch extrem schnelle Lese- und Schreibzugriffe möglich sind. Zusätzlich kann Redis die Daten auf die Festplatte schreiben, um Persistenz zu ermöglichen. Typische Anwendungsfälle: - Caching - Sitzungsverwaltung - Nachrichtenwarteschlangen - Temporäre Datenhaltung - Zentrale Datenablage in Microservice-Architekturen Warum Redis in unserem Projekt? ................................ In unserem containerisierten Web-Stack nutzen wir Redis als zentrale **Datenhaltungsschicht** für die Aufgaben, die vom Frontend erfasst und vom Backend verarbeitet werden. Die Gründe dafür: - **Einfaches Datenmodell**: Key-Value-Prinzip passt gut zu unserem Zweck (z. B. Benutzername → Liste von Aufgaben). - **Schnelle Zugriffszeiten**: Ideal für wiederholte Lese- und Schreibvorgänge. - **Leichtgewichtig**: Sehr ressourcenschonend, ideal für Container. - **Mehrere Clients gleichzeitig**: Sowohl Flask (Backend) als auch Tools wie `redis-cli` oder `Streamlit` können auf Redis zugreifen. Integration in flask ...................... In der Praxis wird Redis als externer Dienst vom Flask-Backend aus angesprochen. Dies erfolgt über eine **Redis-Client-Bibliothek**, z. B. `redis-py`. Beispiel: Benutzer und Einträge in Redis speichern ................................................... .. code-block:: python import redis import json import uuid from flask import Flask, request, jsonify app = Flask(__name__) # Verbindung zu Redis (Docker-Container-Name = "redis") r = redis.Redis(host='redis', port=6379, decode_responses=True) USERS_KEY = "users" def get_entries_key(user_id): return f"user_entries:{user_id}" @app.route('/users', methods=['POST']) def create_user(): users = json.loads(r.get(USERS_KEY)) if r.get(USERS_KEY) else [] data = request.json data['id'] = str(uuid.uuid4()) users.append(data) r.set(USERS_KEY, json.dumps(users)) return jsonify(data), 201 @app.route('/users', methods=['GET']) def get_users(): users = json.loads(r.get(USERS_KEY)) if r.get(USERS_KEY) else [] return jsonify(users) @app.route('/users//entries', methods=['POST']) def add_entry(user_id): key = get_entries_key(user_id) entries = json.loads(r.get(key)) if r.get(key) else [] data = request.json data['id'] = str(uuid.uuid4()) entries.append(data) r.set(key, json.dumps(entries)) return jsonify(data), 201 @app.route('/users//entries', methods=['GET']) def get_entries(user_id): key = get_entries_key(user_id) entries = json.loads(r.get(key)) if r.get(key) else [] return jsonify(entries) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000) Erklärung der Redis-Funktionen .............................. .. list-table:: :widths: 35 65 :header-rows: 1 * - **Funktion** - **Beschreibung** * - ``redis.Redis(...)`` - Verbindet mit Redis im Container-Netzwerk * - ``r.set(USERS_KEY, ...)`` - Speichert alle Benutzer in einem JSON-Array unter dem Key ``users`` * - ``r.get(...)`` - Holt gespeicherte Daten aus Redis * - ``user_entries:`` - Individueller Key zur Speicherung der Einträge eines Benutzers * - ``r.set(key, json.dumps(...))`` - Speichert Einträge als JSON-Liste unter dem User-Key Visualisierung der Redis-Datenstruktur ...................................... :: Redis (Key-Value Store) ├── "users": [ │ {"id": "uuid-1", "name": "Anna"}, │ {"id": "uuid-2", "name": "Max"} │ ] ├── "user_entries:uuid-1": [ │ {"id": "uuid-a", "datum": "2025-04-05", "thema": "Docker", "stunden": 2, "minuten": 30}, │ {"id": "uuid-b", "datum": "2025-04-06", "thema": "Streamlit", "stunden": 1, "minuten": 0} │ ] ├── "user_entries:uuid-2": [ │ {"id": "uuid-c", "datum": "2025-04-03", "thema": "API", "stunden": 1, "minuten": 45} │ ] Datenaustausch im Projekt ............................ .. list-table:: :widths: 25 75 :header-rows: 1 * - **Komponente** - **Aufgabe** * - **Streamlit** - Auswahl, Anzeige und Eingabe durch Benutzer (UI) * - **Flask** - Schnittstelle (API) zur Datenverarbeitung * - **Redis** - Persistente Speicherung von Benutzern und deren Einträgen * - **Streamlit** - Visualisierung aller gespeicherten Daten nach Benutzerfilterung --- Loadbalancing mit NGINX ........................ Zur besseren Lastverteilung und Skalierbarkeit verwenden wir in unserem Projekt einen **NGINX-Container als Loadbalancer**. Dieser verteilt eingehende HTTP-Anfragen gleichmäßig auf zwei Frontend-Container (`frontend1`, `frontend2`), die identische Streamlit-Anwendungen ausführen. NGINX greift dazu auf die Funktionalität von **Upstreams** zurück. Die Konfiguration basiert auf dem Prinzip `least_conn`, bei dem die Verbindung immer zum Frontend mit den aktuell wenigsten offenen Sessions erfolgt. Dies hat folgende Vorteile: - Mehrere Benutzer können gleichzeitig arbeiten, ohne dass ein einzelner Container überlastet wird - Die Anwendung bleibt auch bei steigendem Datenvolumen reaktionsschnell - Container können unabhängig voneinander neu gestartet oder ersetzt werden Beispielkonfiguration (Auszug): .. code-block:: nginx upstream frontends { least_conn; server frontend1:80; server frontend2:80; } server { listen 80; location / { proxy_pass http://frontends; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } } Ablauf einer Benutzeraktion (z.B. Eintrag hinzufügen) ...................................................... 1. **Zugriff über Loadbalancer** Der Benutzer öffnet die Anwendung über `localhost:80`. NGINX leitet die Anfrage automatisch an `frontend1` oder `frontend2` weiter. 2. **Eingabe im Frontend (Streamlit)** Die Streamlit-Oberfläche ermöglicht dem Benutzer die Auswahl eines bestehenden Benutzers oder das Anlegen eines neuen. Anschließend kann ein Eintrag mit Datum, Thema, Stunden und Minuten hinzugefügt werden. 3. **Verarbeitung im Backend (Flask)** Das Frontend sendet die Daten an die API-Endpunkte (`/users`, `/users//entries`). Flask verarbeitet die Anfrage, ergänzt eindeutige IDs und leitet die Daten zur Speicherung weiter. 4. **Speicherung in Redis** - Zu jedem Benutzer werden Einträge unter dem Key ``user_entries:`` geführt 5. **Aktualisierung der Benutzeroberfläche** Nach erfolgreicher Speicherung werden alle Einträge erneut abgerufen und dem Benutzer angezeigt. Die Gesamtzeit aller Einträge wird aus Stunden und Minuten berechnet und als Zusammenfassung dargestellt. Technischer Ablauf (vereinfacht) .................................. :: [Browser des Nutzers] │ ▼ [NGINX Loadbalancer] │ ▼ [Streamlit Frontend 1/2] │ POST /users//entries ▼ [Flask API] │ ▼ [Redis Datenbank] Visualisierung der Redis-Schlüsselstruktur: :: "users" → [ {"id": "...", "name": "Anna"}, {"id": "...", "name": "Ben"} ] "user_entries:" → [ {"datum": "...", "thema": "...", "stunden": 2, "minuten": 30}, ... ] Datenpersistenz mit Docker Volumes ``````````````````````````````````` 1. **Datenverlust bei Neustart:** Ohne persistente Speicherung gehen alle Daten verloren, wenn ein Container neu gestartet wird. 2. **Erschwerte Updates:** Software-Updates erfordern oft einen Neustart oder Neuerstellung des Containers. 3. **Ineffiziente Skalierung:** Bei horizontaler Skalierung müssen Daten zwischen Container-Instanzen geteilt werden. Docker bietet mehrere Lösungen für diese Herausforderungen, wobei Docker Volumes die empfohlene Methode darstellen. Docker Volumes: Grundlagen und Vorteile .......................................... Docker Volumes sind ein Mechanismus zum Speichern von Daten außerhalb des Container-Dateisystems. Sie bieten folgende Vorteile: 1. **Persistenz:** Daten bleiben auch nach Löschung des Containers erhalten 2. **Bessere Performance:** Volumes umgehen die Copy-on-Write-Strategie des Containers 3. **Einfache Sicherung:** Volumes können einfach gesichert und wiederhergestellt werden 4. **Portabilität:** Volumes können zwischen verschiedenen Containern geteilt werden In unserem Projekt nutzen wir Docker Volumes für zwei wichtige Datenspeicherungen: 1. Redis-Datenbank für die Task-Verwaltung Redis-Persistenz mit RDB und Volume-Mount .......................................... Redis ist eine In-Memory-Datenbank, die standardmäßig ihre Daten nur im Arbeitsspeicher hält. Für Persistenz bietet Redis zwei Hauptmechanismen: 1. **RDB (Redis Database):** Punktuelle Snapshots der Datenbank 2. **AOF (Append Only File):** Protokollierung aller Schreiboperationen In unserem Docker-Compose-Setup verwenden wir RDB in Kombination mit einem Docker Volume: .. code-block:: yaml services: redis: image: redis:alpine container_name: redis ports: - "6379:6379" volumes: - redis_data:/data volumes: redis_data: Redis speichert in der standardmäßigen Konfiguration seine RDB-Dateien im Verzeichnis ``/data``. Durch das Mounten des Volumes ``redis_data`` an diesen Pfad werden die RDB-Snapshots außerhalb des Containers gespeichert und bleiben erhalten, auch wenn der Container neu erstellt wird. **Redis-Persistenz im Detail:** 1. Redis schreibt in regelmäßigen Abständen einen Snapshot (dump.rdb) in das Verzeichnis ``/data`` 2. Diese Snapshots enthalten den kompletten Zustand der Datenbank 3. Bei einem Neustart lädt Redis automatisch den letzten Snapshot 4. Dadurch bleiben alle TODO-Listen-Einträge erhalten Sicherheitsaspekte und Best Practices .......................................... Bei der Arbeit mit Docker Volumes und persistenten Daten sollten folgende Sicherheitsaspekte beachtet werden: 1. **Zugriffsrechte:** Volumes sollten mit angemessenen Berechtigungen konfiguriert werden 2. **Backup-Strategie:** Regelmäßige Sicherungen der Volume-Daten 3. **Verschlüsselung:** Sensitive Daten sollten verschlüsselt werden 4. **Isolation:** Jede Anwendung sollte eigene Volumes verwenden In unserem Projekt setzen wir diese Best Practices wie folgt um: - Klare Trennung zwischen flüchtigen und persistenten Daten - Minimale Zugriffsrechte für die Container .. _versuchsdurchfuehrung: Versuchsdurchführung --------------------- Die Grundlage unseres Projekts bildet Docker. Docker ist eine Plattform zur Containervirtualisierung, die es ermöglicht, Anwendungen in isolierten Umgebungen auszuführen. Installationsanleitung: .. code-block:: bash # Für Ubuntu/Debian-basierte Systeme sudo apt-get update sudo apt-get install docker.io docker-compose # Für Windows/macOS # Docker Desktop von https://www.docker.com/products/docker-desktop herunterladen und installieren Nach der Installation sollte geprüft werden, ob Docker korrekt funktioniert: .. code-block:: bash docker --version docker-compose --version Projektstart und Zugriff ````````````````````````` Das gesamte Projekt kann mit einem einzigen Befehl gestartet werden: .. code-block:: bash docker-compose up --build Mit der Option ``-d`` kann die Anwendung im Hintergrund gestartet werden: .. code-block:: bash docker-compose up -d --build Nach dem Start der Container ist die Anwendung unter folgenden URLs erreichbar: - **Todo-Anwendung:** http://localhost:80 - **Benutzer-Frontend:** http://localhost:80/users - **Backend-API:** http://localhost:5000 Zur Überprüfung der Funktionsweise und Zuverlässigkeit unseres containerisierten Systems wurden zwei gezielte Versuche durchgeführt. Diese Tests zeigen das Zusammenspiel der Komponenten unter realistischen Bedingungen und verdeutlichen zentrale Aspekte wie **Load Balancing** und **Datenpersistenz**. Versuche ````````` Versuch 1: Lastverteilung durch den Load Balancer ..................................................... Ziel dieses Versuchs war es, das Verhalten des Load Balancers bei wiederholten Benutzeranfragen zu untersuchen. Dazu wurde die TODO-Anwendung mehrfach im Browser aufgerufen (``http://localhost:80``), wobei der Browser jeweils **neu geladen (Refresh)** wurde. Beobachtung: - Bei jedem Reload wurde der Benutzer zu einer der beiden Frontend-Instanzen weitergeleitet – ``frontend1`` oder ``frontend2`` - Dies zeigte sich visuell durch kleine Unterschiede in der Darstellung (z. B. leicht verändertes Logging im Frontend) - Der Load Balancer (nginx) verteilte die Anfragen im **Round-Robin-Verfahren** Ergebnis: - Die Lastverteilung funktionierte wie erwartet - Beide Frontends wurden gleichmäßig genutzt - Der Benutzer merkt davon im Normalbetrieb nichts – die Anwendung bleibt funktional und reaktionsschnell Diese Beobachtung bestätigt die erfolgreiche Integration von **horizontal skalierbaren Frontend-Komponenten** hinter einem Load Balancer. Versuch 2: Container-Neustart und Prüfung der Datenpersistenz ................................................................ Im zweiten Versuch wurde der Redis-Datenbankcontainer gezielt neugestartet, um die Persistenz der gespeicherten Aufgaben zu überprüfen. Vorgehen: 1. Über die Streamlit-Oberfläche wurde eine neue Aufgabe hinzugefügt. 2. Die erfolgreiche Speicherung wurde durch die Rückmeldung der API bestätigt. 3. Anschließend wurde der Redis-Container gestoppt und neu gestartet: .. code-block:: bash docker-compose restart redis 4. Nach dem Neustart wurde die Anwendung erneut aufgerufen und die gespeicherten Aufgaben abgerufen. Ergebnis: - Die zuvor gespeicherten Aufgaben waren **vollständig erhalten** - Die Persistenzfunktion durch das gemountete Docker Volume funktionierte korrekt - Redis hatte beim Neustart automatisch den letzten Snapshot geladen (``dump.rdb``) Diese erfolgreiche Wiederherstellung der Daten zeigt, dass unser System **ausfallsicher und stabil gegenüber Container-Neustarts** ist – ein zentrales Ziel von containerisierten Produktionsumgebungen. Zusammenfassung: - **Load Balancing funktioniert zuverlässig** und sorgt für gleichmäßige Auslastung der Frontends - **Datenpersistenz über Volumes** gewährleistet, dass Benutzerdaten nicht verloren gehen, selbst bei unerwarteten Neustarts oder Updates einzelner Dienste Diese Tests belegen die Praxistauglichkeit der gewählten Architektur. .. _fazit: Fazit ------ Das Projekt veranschaulicht überzeugend, wie durch den Einsatz von Docker in Kombination mit dem Microservice-Prinzip eine moderne, modulare und skalierbare Webanwendung realisiert werden kann. Die strukturierte Aufteilung in Frontend, Backend und Datenhaltung schafft klare Verantwortlichkeiten und erleichtert sowohl die Entwicklung als auch die Wartung der Anwendung. Durch den Einsatz von Docker Compose wird eine effiziente Orchestrierung der einzelnen Komponenten ermöglicht, was ein konsistentes und reproduzierbares Setup sicherstellt. Zentrale Konzepte wie Container-Isolierung, Netzwerkkommunikation, Datenpersistenz und Load Balancing wurden praxisnah umgesetzt und erfolgreich validiert. Die gewählte Architektur eignet sich nicht nur für Entwicklungszwecke, sondern bietet auch eine tragfähige Grundlage für produktionsnahe Szenarien. Insgesamt bestätigt das Projekt, dass Docker ein leistungsstarkes Werkzeug für die Umsetzung zeitgemäßer Softwarearchitekturen darstellt. Literatur und Quellen ---------------------- - **Flask (Backend-Framework)** https://flask.palletsprojects.com/en/stable/ - **Streamlit (Frontend-Framework)** https://streamlit.io/ - **Redis (Key-Value Store)** https://redis.io/docs/latest/ - **Docker Grundlagen** https://docs.docker.com/get-started/docker-overview/ - **Docker Compose Docs** https://docs.docker.com/compose/ - **RDB in redis** https://medium.com/redis-with-raphael-de-lio/understanding-persistence-in-redis-aof-rdb-on-docker-dcc176ea439